Skip to content

feat(wallet): explicit wallet.restore() recovery mechanism#492

Merged
pietro909 merged 20 commits into
masterfrom
feat/wallet-restore
May 22, 2026
Merged

feat(wallet): explicit wallet.restore() recovery mechanism#492
pietro909 merged 20 commits into
masterfrom
feat/wallet-restore

Conversation

@Kukks
Copy link
Copy Markdown
Contributor

@Kukks Kukks commented May 16, 2026

Summary

Adds an explicit, mode-aware, modular wallet.restore() that recovers a wallet's contracts and balance on a fresh device / wiped repo — closing the gap with go-sdk (gap-limit HD scan) and dotnet-sdk (modular handler discovery).

  • Explicit API: wallet.restore(opts?: { gapLimit?: number }): Promise<void> — never automatic at boot. gapLimit defaults to 20; <= 0 throws.
  • Mode-aware: HD wallets run a gap-limit scan across the index range; static / non-HD wallets restore from the single default pubkey (single pass at index 0). Never throws because of identity/mode. The HD capability check requires both materializeDescriptorAt and advanceLastIndexUsed so a partial custom DescriptorProvider safely takes the static path.
  • Modular discovery seam: a single Discoverable capability interface on ContractHandler. default and delegate handlers implement it; an external boltz/swap package can register a handler that implements it too (it closes over its own Boltz client) — core never imports boltz. Swap hits participate in the same gap-limit loop, so a swap found at HD index i keeps the gap window open and advances the HD watermark.
  • Inline VTXO pull: after the scan, one cursor-safe refreshVtxos({ includeInactive: true }) so getBalance() is correct the instant restore() resolves.
  • Safety-critical error contract: per-handler discoverAt errors are collected, not thrown — the scan finishes, the inline pull recovers safely-discovered funds, then an AggregateError is thrown listing the failed handlers. Fatal/structural errors (indexer unreachable, descriptor materialization failure) propagate immediately so a truncated restore is never silent.
  • Idempotent (createContract dedupes on script), concurrent calls coalesce, monotonic + input-validated HD watermark, dispose() drains an in-flight restore.

Design

Full design + rationale (Sections 1–5, brainstormed and approved before implementation) lives in docs/superpowers/specs/2026-05-16-restore-mechanism-design.md. Note: docs/ is gitignored in this repo, so the spec/plan are local working artifacts and not part of this diff — the behavior they describe is what's implemented and tested here.

Key changes

  • src/contracts/types.tsDiscoverable, DiscoveryDeps, DiscoveredContract, isDiscoverable.
  • src/contracts/contractManager.tsscanContracts() gap-limit loop + ScanResult/ScanContractsOptions/HandlerError; IContractManager extended.
  • src/contracts/handlers/{default,delegate}.ts — implement discoverAt.
  • src/contracts/metadata.ts (new) — WALLET_RECEIVE_SOURCE extracted here to break a contracts → wallet import cycle; re-exported from walletReceiveRotator for back-compat.
  • src/wallet/hdDescriptorProvider.ts — public pure materializeDescriptorAt; monotonic, input-validated advanceLastIndexUsed.
  • src/wallet/walletReceiveRotator.tspickActiveReceive deterministic tiebreak on HD index (so a tight-loop restore doesn't re-advertise an old address); signingDescriptorIndex helper; reuses the extracted deriveDescriptorLeafPubKey.
  • src/identity/descriptor.ts — exported deriveDescriptorLeafPubKey (extracted from the rotator; layering fix).
  • src/wallet/wallet.tsWallet.restore() + _runRestore + in-flight guard; dispose() drains it.
  • src/index.ts / src/contracts/index.ts — public exports for the new types.

Test plan

  • Unit (test/restore.test.ts): helper extraction; pure/monotonic/input-validated HD provider methods; isDiscoverable; default & delegate discoverAt (untagged index-0 vs tagged index>0, multi-timelock, partial-timelock-hit, no-delegatePubKey); scanContracts (gapLimit validation, swap-keeps-gap-open asserting the full post-hit probe window, per-handler error collected vs materialize-throw fatal, static single-pass); signingDescriptorIndex tiebreak; Wallet.restore() (invalid gapLimit, static recovery, HD watermark advance, concurrent coalesce, handler-error rejects after the inline pull, dispose-races-restore). Full unit suite green (1223 passed, 1 pre-existing skip).
  • E2E (test/e2e/restore.test.ts): HD-mode, load-bearing — fund Wallet A at index-0 (rotator advances to index-1), fund again at index-1, then a fresh-repo Wallet B on the same seed whose index-0 baseline cannot cover the index-1 funds → B.restore() recovers the full balance via the gap scan. Wallets disposed in finally. Runs (and passes) in CI via pnpm test:integration-docker against the regtest stack.

Known limitations / follow-ups

  • ServiceWorkerWallet: does not extend Wallet, so it does not inherit restore(). The SW contract-manager proxy explicitly rejects scanContracts (its materialize callback is not structured-cloneable across the worker message boundary). Service-worker restore is out of scope for this change — follow-up.
  • Per-contract hydration during scan: createContract() called per discovered contract triggers a per-contract indexer fetch, redundant with the final batched refreshVtxos({ includeInactive: true }). Correctness is unaffected (the final pull guarantees balance); this is a bounded perf cost on a rare explicit op. A watch-only persistence path is deferred to a follow-up rather than adding a new API to protocol-critical fund-recovery code in this PR.

Review notes

CodeRabbit feedback addressed (HD capability check now requires both HD methods; advanceLastIndexUsed input-validated; test robustness: full post-hit probe-window assertion, per-script mock indexer + partial-hit coverage, guaranteed e2e disposal, unique mock outpoints, guarded test cast). The two items above are deliberate, reasoned deferrals. The arkana/policy CHANGES_REQUESTED is a protocol-critical human-sign-off gate, not outstanding defects.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Added contract discovery scanning for HD wallets to locate contracts across multiple derivation indices with configurable gap limits and robust error collection.
    • Added wallet restore method to synchronize wallet state and recover funds across rotated receive addresses with comprehensive error reporting.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 538ea34b-2712-4559-bbe8-fd4887467245

📥 Commits

Reviewing files that changed from the base of the PR and between 19a0e67 and fb8cfab.

📒 Files selected for processing (17)
  • packages/ts-sdk/src/contracts/contractManager.ts
  • packages/ts-sdk/src/contracts/handlers/default.ts
  • packages/ts-sdk/src/contracts/handlers/delegate.ts
  • packages/ts-sdk/src/contracts/index.ts
  • packages/ts-sdk/src/contracts/metadata.ts
  • packages/ts-sdk/src/contracts/types.ts
  • packages/ts-sdk/src/identity/descriptor.ts
  • packages/ts-sdk/src/index.ts
  • packages/ts-sdk/src/wallet/hdDescriptorProvider.ts
  • packages/ts-sdk/src/wallet/serviceWorker/wallet.ts
  • packages/ts-sdk/src/wallet/wallet.ts
  • packages/ts-sdk/src/wallet/walletReceiveRotator.ts
  • packages/ts-sdk/test/e2e/restore.test.ts
  • packages/ts-sdk/test/e2e/utils.ts
  • packages/ts-sdk/test/helpers/hdProvider.ts
  • packages/ts-sdk/test/helpers/restoreWallet.ts
  • packages/ts-sdk/test/helpers/scanManager.ts
💤 Files with no reviewable changes (17)
  • packages/ts-sdk/test/helpers/scanManager.ts
  • packages/ts-sdk/src/contracts/index.ts
  • packages/ts-sdk/src/identity/descriptor.ts
  • packages/ts-sdk/test/helpers/hdProvider.ts
  • packages/ts-sdk/src/contracts/metadata.ts
  • packages/ts-sdk/src/contracts/types.ts
  • packages/ts-sdk/test/e2e/restore.test.ts
  • packages/ts-sdk/src/contracts/handlers/default.ts
  • packages/ts-sdk/src/wallet/wallet.ts
  • packages/ts-sdk/test/e2e/utils.ts
  • packages/ts-sdk/src/wallet/walletReceiveRotator.ts
  • packages/ts-sdk/src/index.ts
  • packages/ts-sdk/src/contracts/handlers/delegate.ts
  • packages/ts-sdk/src/contracts/contractManager.ts
  • packages/ts-sdk/test/helpers/restoreWallet.ts
  • packages/ts-sdk/src/wallet/hdDescriptorProvider.ts
  • packages/ts-sdk/src/wallet/serviceWorker/wallet.ts

Walkthrough

This PR implements HD wallet contract recovery scanning by introducing a new Wallet.restore() API that performs gap-limit-based contract discovery. The change includes discoverable handler implementations, a ContractManager.scanContracts() method, HD provider enhancements, and comprehensive test infrastructure.

Changes

HD Wallet Restore and Contract Discovery

Layer / File(s) Summary
Discovery API types and infrastructure
src/contracts/types.ts
Introduces DiscoveredContract, DiscoveryDeps, Discoverable interface with discoverAt method, and isDiscoverable type guard function to enable handler discovery pattern.
Discovery utility functions and constants
src/identity/descriptor.ts, src/contracts/metadata.ts, src/wallet/walletReceiveRotator.ts
Adds deriveDescriptorLeafPubKey() for descriptor-based pubkey extraction, WALLET_RECEIVE_SOURCE sentinel constant for contract tagging, and signingDescriptorIndex() helper for HD child-index parsing from descriptors.
Handler discoverable implementations
src/contracts/handlers/default.ts, src/contracts/handlers/delegate.ts
Extends DefaultContractHandler and DelegateContractHandler with discoverAt methods that implement the discovery pattern by deriving leaf pubkeys from descriptors, querying indexers for matching scripts, and returning discovered contract instances with optional metadata based on HD index.
ContractManager.scanContracts core scanning
src/contracts/contractManager.ts
Implements gap-limit-based contract discovery scanning API with validation, discoverable handler filtering, HD index iteration, per-handler error collection, fatal error propagation, hard ceiling enforcement (SCAN_MAX_INDEX = 10_000), and contract persistence via refactored upsertContract and persistAndWatchContract helpers.
HDDescriptorProvider gap-scan support
src/wallet/hdDescriptorProvider.ts
Renames internal materializeAt to materializeDescriptorAt (documented as pure read), adds public advanceLastIndexUsed(index) for monotonic watermark advancement with atomic persistence, and updates getNextSigningDescriptor() and getCurrentSigningDescriptor() call sites.
Wallet.restore() entry point and orchestration
src/wallet/wallet.ts
Implements public restore(opts?) API with synchronous validation, private _restoreInFlight promise field for concurrency coalescing, delegated _runRestore(gapLimit) that orchestrates scanning/index advancement/VTXO refresh, error aggregation via AggregateError, and dispose() cleanup to await in-flight restore before teardown.
Wallet receive-rotation updates
src/wallet/walletReceiveRotator.ts
Re-exports WALLET_RECEIVE_SOURCE from contracts metadata, refactors deriveLeafPubkey to wrap the new descriptor utility, and updates receive-contract selection sort order to use signingDescriptorIndex as a secondary tie-breaker after createdAt timestamp.
Public API surface and package exports
src/contracts/index.ts, src/index.ts, src/wallet/serviceWorker/wallet.ts
Re-exports discovery types (Discoverable, DiscoveryDeps, DiscoveredContract, ScanResult, ScanContractsOptions, HandlerError) and isDiscoverable function from contract manager and types. Adds scanContracts() stub to service-worker proxy that rejects with error explaining postMessage boundary limitation.
Test helpers and mock providers
test/helpers/hdProvider.ts, test/helpers/restoreWallet.ts, test/helpers/scanManager.ts, test/e2e/utils.ts
Provides makeHdProviderForTest() for HD descriptor setup, makeManagerForTest() and makeDeps() for contract manager mocking, restore harness with deterministic MockIndexer/MockOnchain and installRestoreHarness()/teardownRestoreHarness() globals, and wallet factory helpers makeStaticWalletForTest(), makeHdWalletForTest(), and createTestArkWalletFromMnemonic().
Comprehensive restore and scanning test suite
test/e2e/restore.test.ts
Implements end-to-end test verifying that Wallet.restore() enables discovery of funds landing on rotated (non-baseline) HD indices by funding two wallet instances on the same mnemonic, with baseline VTXO visibility before restore and full balance recovery after restore completes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • arkade-os/ts-sdk#440: Introduces HDDescriptorProvider which this PR extends with materializeDescriptorAt rename and advanceLastIndexUsed() for restore scanning.
  • arkade-os/ts-sdk#387: Modifies ContractManager.createContract() watcher/cursor behavior that this PR refactors further with upsertContract() and persistAndWatchContract() helpers for discovery batch operations.
  • arkade-os/ts-sdk#271: Changes DefaultContractHandler parameter descriptor format that this PR's discoverAt handler implementation depends on for pubkey derivation.

Suggested reviewers

  • pietro909
  • louisinger
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(wallet): explicit wallet.restore() recovery mechanism' accurately captures the main change—adding an explicit wallet.restore() method for wallet recovery—which aligns with the primary implementation across ContractManager, wallet, handlers, and supporting infrastructure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wallet-restore

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(wallet): explicit wallet.restore() recovery mechanism

Verdict: Request changes (protocol-critical — requires human sign-off)

This is well-architected fund-recovery code with solid error semantics and thorough test coverage. The gap-limit scan, error-collection model, and concurrency guard are all sound. However, this is protocol-critical (VTXO discovery = fund recovery) — bugs here make users believe they have no funds when they do. Requesting human review per repo policy.


🟢 Strengths

  1. Error contract is correct: handler discoverAt errors collected → scan finishes → inline VTXO pull recovers safely-discovered funds → then AggregateError surfaces. A truncated restore is loud, never silent.
  2. Concurrency model is solid: _restoreInFlight coalesces concurrent calls; dispose() drains it deadlock-free (restore never calls dispose).
  3. HD watermark is monotonic: advanceLastIndexUsed uses mutate() (mutex-protected) and only advances.
  4. Cross-repo impact is nil: all changes are additive. No external implementations of IContractManager exist. The materializeAtmaterializeDescriptorAt rename is internal.
  5. Test coverage: 28 unit cases + e2e. Key invariants tested: gap-window-kept-open-by-swap, concurrent coalesce, dispose-races-restore, handler-error-after-pull.

🟡 Minor Issues (non-blocking, but worth addressing)

1. Unbounded loop if a buggy handler always returns results
src/contracts/contractManager.ts:131while (i <= maxIdx && unused < gapLimit) with maxIdx = POSITIVE_INFINITY for HD mode. If a malicious/buggy third-party Discoverable handler always returns non-empty from discoverAt, the loop never terminates.

Suggestion: Add a hard ceiling (e.g. const MAX_INDEX = 10_000) as a safety valve. Gap-limit 20 with continuous hits reaching 10k indices would already be extraordinary; an infinite loop would OOM/hang the wallet.

const maxIdx = opts.hd ? MAX_INDEX : 0;

2. Sequential handler probing — potential performance cliff
src/contracts/contractManager.ts:135-145 — handlers are iterated sequentially within each index. With N handlers × G gap-limit × T timelocks per handler, each making an indexer round-trip, a restore with 3 handlers and gap 20 would make ~60+ serial network calls minimum.

Suggestion (future): Consider Promise.all across handlers within an index (they're independent). Not blocking this PR, but worth a TODO comment.

3. Duck-typing for HD detection is fragile
src/wallet/wallet.ts:1192-1194:

const hd = !!provider && typeof (provider as Partial<HDDescriptorProvider>).materializeDescriptorAt === "function";

Any non-HD DescriptorProvider that happens to have a materializeDescriptorAt method would be incorrectly treated as HD. Consider using instanceof HDDescriptorProvider (you already import it) for type safety:

const hd = provider instanceof HDDescriptorProvider;

🟢 No Issues Found In

  • Gap-limit termination logic (correct BIP44-style scan)
  • createContract idempotency reliance (script-keyed dedup is existing behavior)
  • Metadata tagging (index 0 untagged, index > 0 tagged with wallet-receive + signingDescriptor)
  • deriveDescriptorLeafPubKey extraction (logic identical to original)
  • WALLET_RECEIVE_SOURCE layering fix (re-export maintains back-compat)
  • pickActiveReceive tiebreak (deterministic on HD index)
  • ServiceWorker proxy rejection (clear error, correct — callbacks not structured-cloneable)
  • signingDescriptorIndex regex (correctly parses trailing /N) pattern)

⚠️ Protocol-Critical Flag

This PR adds fund-recovery logic. If scanContracts terminates early (gap closes before all funds discovered), or if refreshVtxos fails silently after the scan, users lose visibility of their own money. The error contract handles both cases correctly in the current implementation, but this requires human sign-off before merge per protocol-critical review policy.


TL;DR: Clean, well-tested, correct error semantics. The unbounded-loop ceiling (#1) should be addressed before merge; #2-#3 are nice-to-haves. Human approval required for protocol-critical code.

Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(wallet): explicit wallet.restore() recovery mechanism

Verdict: Request changes (protocol-critical — requires human sign-off)

This is well-architected fund-recovery code with solid error semantics and thorough test coverage. The gap-limit scan, error-collection model, and concurrency guard are all sound. However, this is protocol-critical (VTXO discovery = fund recovery) — bugs here make users believe they have no funds when they do. Requesting human review per repo policy.


🟢 Strengths

  1. Error contract is correct: handler discoverAt errors collected → scan finishes → inline VTXO pull recovers safely-discovered funds → then AggregateError surfaces. A truncated restore is loud, never silent.
  2. Concurrency model is solid: _restoreInFlight coalesces concurrent calls; dispose() drains it deadlock-free (restore never calls dispose).
  3. HD watermark is monotonic: advanceLastIndexUsed uses mutate() (mutex-protected) and only advances.
  4. Cross-repo impact is nil: all changes are additive. No external implementations of IContractManager exist. The materializeAtmaterializeDescriptorAt rename is internal.
  5. Test coverage: 28 unit cases + e2e. Key invariants tested: gap-window-kept-open-by-swap, concurrent coalesce, dispose-races-restore, handler-error-after-pull.

🟡 Minor Issues (non-blocking, but worth addressing)

1. Unbounded loop if a buggy handler always returns results
src/contracts/contractManager.ts:131while (i <= maxIdx && unused < gapLimit) with maxIdx = POSITIVE_INFINITY for HD mode. If a malicious/buggy third-party Discoverable handler always returns non-empty from discoverAt, the loop never terminates.

Suggestion: Add a hard ceiling (e.g. const MAX_INDEX = 10_000) as a safety valve. Gap-limit 20 with continuous hits reaching 10k indices would already be extraordinary; an infinite loop would OOM/hang the wallet.

const maxIdx = opts.hd ? MAX_INDEX : 0;

2. Sequential handler probing — potential performance cliff
src/contracts/contractManager.ts:135-145 — handlers are iterated sequentially within each index. With N handlers × G gap-limit × T timelocks per handler, each making an indexer round-trip, a restore with 3 handlers and gap 20 would make ~60+ serial network calls minimum.

Suggestion (future): Consider Promise.all across handlers within an index (they're independent). Not blocking this PR, but worth a TODO comment.

3. Duck-typing for HD detection is fragile
src/wallet/wallet.ts:1192-1194:

const hd = !!provider && typeof (provider as Partial<HDDescriptorProvider>).materializeDescriptorAt === "function";

Any non-HD DescriptorProvider that happens to have a materializeDescriptorAt method would be incorrectly treated as HD. Consider using instanceof HDDescriptorProvider (you already import it) for type safety:

const hd = provider instanceof HDDescriptorProvider;

🟢 No Issues Found In

  • Gap-limit termination logic (correct BIP44-style scan)
  • createContract idempotency reliance (script-keyed dedup is existing behavior)
  • Metadata tagging (index 0 untagged, index > 0 tagged with wallet-receive + signingDescriptor)
  • deriveDescriptorLeafPubKey extraction (logic identical to original)
  • WALLET_RECEIVE_SOURCE layering fix (re-export maintains back-compat)
  • pickActiveReceive tiebreak (deterministic on HD index)
  • ServiceWorker proxy rejection (clear error, correct — callbacks not structured-cloneable)
  • signingDescriptorIndex regex (correctly parses trailing /N) pattern)

⚠️ Protocol-Critical Flag

This PR adds fund-recovery logic. If scanContracts terminates early (gap closes before all funds discovered), or if refreshVtxos fails silently after the scan, users lose visibility of their own money. The error contract handles both cases correctly in the current implementation, but this requires human sign-off before merge per protocol-critical review policy.


TL;DR: Clean, well-tested, correct error semantics. The unbounded-loop ceiling (#1) should be addressed before merge; #2-#3 are nice-to-haves. Human approval required for protocol-critical code.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
test/restore.test.ts (2)

426-468: ⚡ Quick win

Assert that the scan really continues past the hit.

This regression test says the hit at index 4 keeps the window open, but the current assertions only prove the hit was found and lastIndexUsed became 4. A buggy loop that stops immediately after the first hit would still pass. Capture calls from makeFakeHandler() and assert the handler was probed through the full post-hit window.

♻️ Suggested assertion
-        const { handler } = makeFakeHandler("swapfake", (i) =>
+        const { handler, calls } = makeFakeHandler("swapfake", (i) =>
             i === 4
                 ? [
                       {
                           type: "swapfake",
                           params: { script: "aabb" },
@@
             // so the loop kept probing 5..9 instead of stopping at 4, and
             // lastIndexUsed is driven solely by the swap handler.
             expect(res.lastIndexUsed).toBe(4);
             expect(res.handlerErrors).toEqual([]);
+            expect(calls).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
             // The contract was actually registered (idempotent createContract).
             const [c] = await mgr.getContracts({ script: "aabb" });
             expect(c?.type).toBe("swapfake");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/restore.test.ts` around lines 426 - 468, The test currently only asserts
that a swap hit at index 4 was found and lastIndexUsed is 4, but doesn't verify
the scanner actually probed indices after the hit; capture the handler
invocation details returned by makeFakeHandler (the `handler`/`calls` structure)
and add an assertion that the handler was invoked for indices beyond 4 up to the
expected post-hit window (given gapLimit:5), e.g., verify `calls` includes
probes for indices 5..(4 + gapLimit - 1) or the equivalent sequence, so the test
fails if the loop stopped immediately after the hit; update the test to extract
`calls` from makeFakeHandler and assert the expected indices were probed.

102-111: ⚡ Quick win

Cover the partial-hit timelock case.

mockIndexer() reports success when any queried script matches, and both multi-timelock tests only cover the “all scripts hit” path. A handler regression that batches timelocks together and treats one hit as evidence for every candidate would still pass here. Please add a case where only one timelock script exists, or make the mock return per-script hits so the suite can catch false positives.

Also applies to: 174-220, 302-350

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/restore.test.ts` around lines 102 - 111, The mockIndexer’s getVtxos
currently returns a single boolean hit if any queried script matches, which
masks partial-hit bugs; update mockIndexer (and tests at the other locations) so
getVtxos returns per-script results (or at least an explicit case where only one
timelock script is present) — e.g., have mockIndexer examine each script in
opts.scripts and return vtxos only for the scripts that are in usedScripts (or
add a new test that supplies two timelock scripts but only marks one as used and
asserts the handler does not treat both as found); target the mockIndexer
function and getVtxos behavior used by the multi-timelock tests to ensure
partial-hit cases are covered.
test/e2e/restore.test.ts (1)

22-59: ⚡ Quick win

Always dispose the e2e wallets in finally.

If any assertion fails before the happy-path disposals, these wallet instances stay alive and can leak repo/indexer state into later e2e runs. Please wrap both wallet lifecycles in try/finally so cleanup is guaranteed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/restore.test.ts` around lines 22 - 59, The test creates two wallets
via createTestArkWalletFromMnemonic (variables a and b) but only disposes them
on the happy path; move both lifecycles into try/finally blocks so each wallet
is always disposed even if assertions fail: surround the logic after creating a
with try { ... } finally { await a.wallet.dispose(); } and likewise surround the
logic after creating b (using createSharedRepos and calling
faucetOffchain/waitFor/getBalance/restore) with a try/finally that awaits
b.wallet.dispose(); ensure awaits remain and no test logic is left outside the
try so cleanup is guaranteed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/contracts/contractManager.ts`:
- Around line 528-537: The discovery loop currently calls createContract() which
immediately triggers full hydration via fetchContractVxosFromIndexer() and
watcher registration; to avoid N full indexer pulls during restore, add a new
lightweight API (e.g., persistAndWatchContract or createContractWatchOnly) on
the same class that only persists the DiscoveredContract and registers watchers
but does not call fetchContractVxosFromIndexer(), then replace the
createContract(c) call inside the discoverables loop with this new method; keep
createContract(c) intact for later restore flow where refreshVtxos({
includeInactive: true }) will perform the full hydration. Ensure the new method
signature accepts the DiscoveredContract and any opts needed and that any error
handling (handlerErrors) remains unchanged.

In `@src/wallet/hdDescriptorProvider.ts`:
- Around line 134-142: The advanceLastIndexUsed method currently accepts invalid
watermarks; before calling mutate() add a guard that validates the incoming
index using Number.isInteger(index) && index >= 0 and bail out (no-op) if it
fails so you never persist NaN, fractions, or negatives to
settings.lastIndexUsed; ensure you reference advanceLastIndexUsed, mutate, and
settings.lastIndexUsed in the change and note that invalid values currently
break parseSettings on subsequent reads.

In `@src/wallet/wallet.ts`:
- Around line 1229-1243: The current HD branch uses hd inferred only from
materializeDescriptorAt but later calls advanceLastIndexUsed, causing a
TypeError; update the capability check to require both functions
(materializeDescriptorAt and advanceLastIndexUsed) on this._descriptorProvider
(e.g., replace hd with hdCapable or similar), narrow the type to an
HDDescriptorProvider-like shape for the rest of _runRestore, and adjust the
materialize function and subsequent calls (including where advanceLastIndexUsed
is invoked) to rely on that guarded, correctly typed provider so custom
providers that only implement one method won't take the HD path.
- Around line 1213-1224: Move the early-return that checks this._restoreInFlight
to before any validation of opts so concurrent calls coalesce; specifically, in
restore() check if (this._restoreInFlight) return this._restoreInFlight; first,
then compute/validate gapLimit and set this._restoreInFlight =
this._runRestore(gapLimit).finally(...). Ensure you still validate gapLimit
(Number.isInteger and >0) only for the caller that actually starts the run, and
keep the same error message when throwing.

In `@test/helpers/restoreWallet.ts`:
- Around line 84-95: makeVtxo currently returns a constant outpoint (txid/vout)
which causes accidental deduping; update makeVtxo to generate a unique outpoint
per call (e.g., derive txid or vout from the script, a counter, timestamp, or a
short hash of script+Date.now()) so each VirtualCoin has a distinct txid/vout
pair while preserving other fields (value, status, createdAt, script,
isUnrolled, isSpent, virtualStatus). Apply the same uniqueness fix to the other
similar helper(s) referenced in the file (the alternate makeVtxo usage around
the later lines 125-127) so all mocked VTXOs use unique outpoints.
- Around line 259-261: The current unchecked cast retrieving hdProvider from
wallet (const hdProvider = (wallet as unknown as { _descriptorProvider:
HDDescriptorProvider })._descriptorProvider) should be protected by a runtime
guard: verify wallet._descriptorProvider exists and matches the expected shape
(e.g., instanceof HDDescriptorProvider or has required methods/properties) and
if not throw a clear, fast-failing error mentioning wallet and
HDDescriptorProvider; replace the direct cast with this guard and use the
validated hdProvider thereafter so failures are explicit if wallet internals
change.

---

Nitpick comments:
In `@test/e2e/restore.test.ts`:
- Around line 22-59: The test creates two wallets via
createTestArkWalletFromMnemonic (variables a and b) but only disposes them on
the happy path; move both lifecycles into try/finally blocks so each wallet is
always disposed even if assertions fail: surround the logic after creating a
with try { ... } finally { await a.wallet.dispose(); } and likewise surround the
logic after creating b (using createSharedRepos and calling
faucetOffchain/waitFor/getBalance/restore) with a try/finally that awaits
b.wallet.dispose(); ensure awaits remain and no test logic is left outside the
try so cleanup is guaranteed.

In `@test/restore.test.ts`:
- Around line 426-468: The test currently only asserts that a swap hit at index
4 was found and lastIndexUsed is 4, but doesn't verify the scanner actually
probed indices after the hit; capture the handler invocation details returned by
makeFakeHandler (the `handler`/`calls` structure) and add an assertion that the
handler was invoked for indices beyond 4 up to the expected post-hit window
(given gapLimit:5), e.g., verify `calls` includes probes for indices 5..(4 +
gapLimit - 1) or the equivalent sequence, so the test fails if the loop stopped
immediately after the hit; update the test to extract `calls` from
makeFakeHandler and assert the expected indices were probed.
- Around line 102-111: The mockIndexer’s getVtxos currently returns a single
boolean hit if any queried script matches, which masks partial-hit bugs; update
mockIndexer (and tests at the other locations) so getVtxos returns per-script
results (or at least an explicit case where only one timelock script is present)
— e.g., have mockIndexer examine each script in opts.scripts and return vtxos
only for the scripts that are in usedScripts (or add a new test that supplies
two timelock scripts but only marks one as used and asserts the handler does not
treat both as found); target the mockIndexer function and getVtxos behavior used
by the multi-timelock tests to ensure partial-hit cases are covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 310ef6b3-7dac-4e9d-a7b5-480fbccaa359

📥 Commits

Reviewing files that changed from the base of the PR and between d663d15 and 38f5537.

📒 Files selected for processing (18)
  • src/contracts/contractManager.ts
  • src/contracts/handlers/default.ts
  • src/contracts/handlers/delegate.ts
  • src/contracts/index.ts
  • src/contracts/metadata.ts
  • src/contracts/types.ts
  • src/identity/descriptor.ts
  • src/index.ts
  • src/wallet/hdDescriptorProvider.ts
  • src/wallet/serviceWorker/wallet.ts
  • src/wallet/wallet.ts
  • src/wallet/walletReceiveRotator.ts
  • test/e2e/restore.test.ts
  • test/e2e/utils.ts
  • test/helpers/hdProvider.ts
  • test/helpers/restoreWallet.ts
  • test/helpers/scanManager.ts
  • test/restore.test.ts

Comment thread src/contracts/contractManager.ts Outdated
Comment thread packages/ts-sdk/src/wallet/hdDescriptorProvider.ts
Comment thread packages/ts-sdk/src/wallet/wallet.ts
Comment thread src/wallet/wallet.ts Outdated
Comment thread packages/ts-sdk/test/helpers/restoreWallet.ts
Comment thread test/helpers/restoreWallet.ts Outdated
Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review (commit 0aaac20)

New commit: test(e2e): make restore test HD-mode and load-bearing — test-only, no production code changed.

✅ New test is correct and well-designed

The old e2e test was vacuous — index-0 funds are auto-registered at baseline, so a same-seed wallet B would see them without restore(). The new test is genuinely load-bearing:

  1. Funds wallet A at index-0 → receive rotator advances to index-1
  2. Funds wallet A at index-1 (a script baseline doesn't cover)
  3. Fresh wallet B on same seed → baseline only sees index-0 → before < totalA
  4. After restore() → gap scan discovers index-1 → after >= totalA

The waitFor polling for rotation and the toBeLessThan assertion (instead of === 0) correctly handle the timing window where B's watcher may or may not have synced index-0 VTXOs. Good.

The utils.ts change (adding optional walletMode param) is clean — no default changed, existing callers unaffected.

⚠️ Previous review concerns still open

My prior review's findings remain unaddressed:

  1. Unbounded loop (contractManager.ts:518maxIdx = POSITIVE_INFINITY) — still needs a hard ceiling
  2. Duck-typing for HD detection (wallet.ts:1233) — still uses method-existence check instead of instanceof

These are non-blocking for this test commit but still need resolution before merge. Protocol-critical flag and human sign-off requirement remain.

@pietro909 pietro909 self-assigned this May 18, 2026
@pietro909 pietro909 self-requested a review May 18, 2026 07:40
Kukks and others added 20 commits May 21, 2026 14:33
Add the Discoverable capability to DefaultContractHandler so it
participates in wallet.restore()'s gap-limit scan. discoverAt probes
every csvTimelock in the baseline matrix at the given descriptor's leaf
pubkey and returns any contracts the indexer shows on-chain history for.
Index 0 produces an untagged DiscoveredContract; index > 0 tags with
wallet-receive source + signingDescriptor metadata.

Type annotation widened to ContractHandler & Discoverable.
…→wallet cycle

Move the source-of-truth declaration of WALLET_RECEIVE_SOURCE to the
new dependency-free leaf src/contracts/metadata.ts. Update
contracts/handlers/default.ts to import from ../metadata (dropping the
../../wallet/walletReceiveRotator import that caused the cycle). Add a
re-export from wallet/walletReceiveRotator for backward compatibility.
…asts

Add a test that calls discoverAt with two distinct csvTimelocks, asserts
both are returned as separate entries, and checks each entry's
params.csvTimelock serializes to its own timelockToSequence value.

Remove as any from isDiscoverable(DefaultContractHandler) and
DefaultContractHandler.discoverAt(...) — the exported const is already
typed ContractHandler<...> & Discoverable so both call sites type-check
without casts. Keep as any only on the intentionally partial mock
objects (mockIndexer, onchainProvider stubs).
…st import

- Rename local `lastUsedIdx` → `lastIndexUsed` in `scanContracts` so the
  return statement reads `return { lastIndexUsed, handlerErrors }` with no
  aliasing double-take.
- Tighten `let found;` to `let found: DiscoveredContract[];` (catch does
  `continue`, so it is always assigned before use; removes the inferred
  `| undefined`). Add `DiscoveredContract` to the `./types` import.
- Update `ScanContractsOptions.hd` JSDoc: false branch now reads
  "probe only index 0 (single static pass)" for precision.
- Remove the unused `import { ContractManager }` from `test/restore.test.ts`;
  only `makeManagerForTest()` / `makeDeps` from the helper are used in code.
- dispose() now awaits _restoreInFlight?.catch(() => undefined) before
  touching the contract/vtxo managers, preventing _runRestore from
  calling manager.refreshVtxos() or manager.scanContracts() on a torn-
  down manager. _runRestore never calls dispose(), so this is deadlock-free.

- staticDescriptor in _runRestore is now computed lazily (only in the
  non-HD branch), avoiding an unnecessary xOnlyPublicKey() derivation
  for HD wallets where the static descriptor is never used.

- JSDoc on restore() notes that concurrent calls coalesce and that a
  second caller's gapLimit is ignored while a restore is in flight.

- Regression test: wallet.restore() + immediate wallet.dispose() (without
  awaiting restore first) must resolve dispose without throwing, and the
  restore promise must settle without an unhandled rejection.
Surface Discoverable, DiscoveryDeps, DiscoveredContract, isDiscoverable
(from src/contracts/types via src/contracts/index.ts) and ScanResult,
ScanContractsOptions, HandlerError (from contractManager) through the
package root src/index.ts, following the existing curated import/export
pattern for contract types.

Cleanups from task review:
- Remove spurious await on synchronous materializeDescriptorAt calls in
  test/restore.test.ts (method returns string, not Promise<string>).
- Add inline comment above the /\/(\d+)\)\s*$/ regex in
  walletReceiveRotator.ts explaining what it captures.
- Consolidate all mid-file import blocks in test/restore.test.ts into a
  single top-of-file block.
…D check

- scanContracts now caps the HD index range at SCAN_MAX_INDEX (10_000)
  instead of POSITIVE_INFINITY. Hitting the ceiling without closing the
  gap window throws — a buggy/malicious Discoverable handler returning
  unconditional hits would otherwise hang the wallet, and silently
  truncating a fund-recovery scan would mask the failure.

- _runRestore detects HD via instanceof HDDescriptorProvider instead of
  duck-typing materializeDescriptorAt/advanceLastIndexUsed. The previous
  structural check would mis-classify any non-HD provider that happened
  to expose those method names.

Addresses arkanaai review on PR #492.
Each scanContracts hit was routed through createContract, which calls
fetchContractVxosFromIndexer([contract]) per contract. Wallet.restore
then follows up with a single bulk refreshVtxos({ includeInactive: true })
that already covers the same scripts in one batched indexer call — making
the per-contract pulls redundant work proportional to scan hits.

Factor the validate + dedupe + persist core into upsertContract, and
add a lightweight persistAndWatchContract that omits the indexer fetch.
scanContracts now uses the lighter method; createContract keeps the
fetch for standalone callers that don't have a trailing refreshVtxos
to lean on.
The docstring already promises that a second caller's gapLimit is
ignored while a restore is in flight, but the validation guard fired
before the in-flight check — so a coalescing caller with an invalid
gapLimit would throw instead of joining the running scan as documented.
Move the _restoreInFlight check first; only the caller that actually
starts the run gets its gapLimit validated. Error message unchanged.
@pietro909 pietro909 force-pushed the feat/wallet-restore branch from 19a0e67 to fb8cfab Compare May 21, 2026 13:15
Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iterative review — commits 400829f through fb8cfab

Both open concerns from my previous review are now resolved:

✅ Unbounded loop → bounded at SCAN_MAX_INDEX = 10_000

contractManager.ts:38 — hard ceiling with a clear safety-valve comment. The post-loop check (i > maxIdx && unused < gapLimit) correctly distinguishes "gap closed naturally" from "hit the ceiling" and throws loudly for the latter. Good.

✅ Duck-typing → instanceof HDDescriptorProvider

wallet.ts:~1105const hd = provider instanceof HDDescriptorProvider. Comment documents the trade-off if custom HD providers are added later. Clean.

✅ New: upsertContract / persistAndWatchContract refactor (commit 3d15d5e3)

Well-factored split. persistAndWatchContract skips the per-contract fetchContractVxosFromIndexer round-trip, reducing N+1 indexer calls to 1 bulk refreshVtxos({ includeInactive: true }). The shared upsertContract preserves the existing dedupe-by-script and type-mismatch semantics. No behavioral change for external createContract callers.

✅ New: Concurrent coalesce reorder (commit fb8cfabc)

restore() now checks _restoreInFlight before validating gapLimit. Correct — a coalescing caller's value is documented as ignored, so validating it first would surface misleading errors.

✅ New: advanceLastIndexUsed input validation (commit 400829f8)

hdDescriptorProvider.ts:~140if (!Number.isInteger(index) || index < 0) return; silently no-ops on invalid input instead of corrupting lastIndexUsed. The monotonic guard (index > settings.lastIndexUsed) was already present; the new guard prevents NaN/fractions/negatives from bypassing it.

Test coverage

772-line unit suite + load-bearing e2e test. Coverage includes: gap-limit validation, swap-keeps-gap-open with full post-hit probe-window assertion (calls === [0..9]), per-handler error collection vs fatal propagation, static single-pass, concurrent coalesce, dispose-races-restore, HD watermark advance, partial-timelock-hit, per-script mock indexer. Comprehensive.

Protocol-critical flag

This PR touches fund recovery — a wallet.restore() that silently truncates or misidentifies scripts risks hiding user funds. The code is correct and well-tested, but human sign-off is required before merge per protocol-critical review policy.

🤖 Arkana

Copy link
Copy Markdown
Contributor

@pietro909 pietro909 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to go from my side @Kukks your call since I've pushed some updates. Planning a follow-up PR for service worker support.

@pietro909 pietro909 merged commit 70ee929 into master May 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants